<?php
/**
 * Advanced Database API.
 *
 * @author Martin "DevelX" Jurča
 * @version 2008-03-12
 */
class DBAPI {
    /**
     * Database driver.
     *
     * @var DBDriver
     */
    private $driver;

    public function __construct($driver) {
        if ($driver == null) {
            throw new Exception("DBDriver cannot be null");
        }
        $this->driver = $driver;
    }
    
    public function &load(&$dataModel, $conditions = '', &$orderBy = array(),
            $limitFrom = 0, $limitCount = -1) {
        $table = $dataModel['table'];
        $columns = &$this->getDBProperties($dataModel['properties']);
        $columns = array_keys($columns);
        $data = &$this->driver->select($table, $columns, $conditions, $orderBy,
                $limitFrom, $limitCount);
        if ($data === false) {
            return $data;
        }
        $recods = &$this->createRecords($dataModel, $data);
        foreach ($recods as &$record) {
            $this->loadAttachedRecords($record);
            $this->invokeEvent($record, 'load');
        }
        return $recods;
    }

    public function save(&$record) {
        $this->invokeEvent($record, 'save');
        $table = $record['table'];
        $columns = &$this->getDBProperties($record['properties']);
        $values = array();
        $primaryIndex = null;
        foreach ($columns as $name => $type) {
            if (strpos($type, '+') !== false) {
                $primaryIndex = $name;
            }
            $values[$name] = isset($record['data'][$name]) ?
                    $record['data'][$name] : null;
        }
        if (!$this->driver->insert($table, $values)) {
            return false;
        }
        if ($primaryIndex != null) {
            $record['data'][$primaryIndex] = $this->driver->getLastInsertId();
        }
        return true;
    }

    public function update(&$record, &$properties = array()) {
        $this->invokeEvent($record, 'update');
        $keys = &$this->getKeyProperties($record['properties']);
        $conditions = $this->createIdentificationConditions($keys,
                $record['data']);
        if (count($properties) == 0) {
            $properties = &$this->getDBProperties($record['properties']);
        }
        $values = array();
        foreach ($properties as $name => $type) {
            $values[$name] = $record['data'][$name];
        }
        return $this->driver->update($record['table'], $values, $conditions);
    }

    public function delete(&$record) {
        $keys = &$this->getKeyProperties($record['properties']);
        $conditions = $this->createIdentificationConditions($keys,
                $record['data']);
        return $this->driver->delete($record['table'], $conditions);
    }

    public function refresh(&$record) {
        $keys = &$this->getKeyProperties($record['properties']);
        $conditions = $this->createIdentificationConditions($keys,
                $record['data']);
        $columns = &$this->getDBProperties($record['properties']);
        Cache::flushTable($record['table']);
        $columns = array_keys($columns);
        $data = $this->driver->select($record['table'], $columns, $conditions);
        $records = &$this->createRecords($record, $data);
        $result = count($records) > 0;
        if ($result) {
            $record = $records[0];
            $this->loadAttachedRecords($record);
            $this->invokeEvent($record, 'refresh');
        } else {
            $record = false;
        }
        return $result;
    }

    public function invokeEvent(&$record, $event) {
        foreach ($record['properties'] as $property) {
            if (is_string($property) && (substr($property, 0, 1) == 'e')) {
                $eventName = substr($property, 1, strpos($property, ':') - 1);
                if ($event == $eventName) {
                    $command = substr($property, strpos($property, ':') + 1);
                    $this->executeCommand($command, $record['data']);
                }
            }
        }
    }

    public function getDriver() {
        return $this->driver;
    }

    /**
     * Loads attached records to record.
     *
     * @param Array $record Record to be completed with attached records.
     */
    private function loadAttachedRecords(&$record) {
        foreach ($record['properties'] as $name => $property) {
            if (is_array($property)) {
                $condition = "{$record['data'][$property['linkTo']]} " .
                        "{$property['linkOp']} " .
                        "{$property['record']['table']}.{$property['linkBy']}";
                $records = &$this->load($property['record'], $condition);
                if (is_array($records)) {
                    foreach ($records as &$rec) {
                        $this->loadAttachedRecords($rec);
                    }
                }
                $record['data'][$name] = $records;
            }
        }
    }

    /**
     * Executes given comman on given data of the record.
     *
     * @param String $command Command to execute.
     * @param Array $data Data of record.
     */
    private function executeCommand($command, &$data) {
        $command = trim($command);
        $cmd = trim(substr($command, 0, strpos($command, '(')));
        $details = trim(substr($command, strpos($command, '(') + 1, -1));
        if ($cmd == 'inc') {
            $data[$details]++;
        } elseif ($cmd == 'dec') {
            $data[$details]--;
        } elseif ($cmd == 'null') {
            $data[$details] = 0;
        } elseif ($cmd == 'set') {
            $column = trim(substr($details, 0, strpos($details, ',')));
            $relation = substr($details, strpos($details, ',') + 1);
            $data[$column] = $this->evalRelation($relation, $data);
        } elseif ($cmd == 'func') {
            $column = trim(substr($details, 0, strpos($details, ',')));
            $func = trim(substr($details, strpos($details, ',') + 1));
            $data[$column] = $func($data);
        }
    }

    /**
     * Creates condition of given keys and data that may be used to select
     * row in the database of desired record.
     *
     * @param Array $keys List of properties creating superkey.
     * @param Array $data Data of the record.
     * @return String Condition that selects row in the database of the record.
     */
    private function createIdentificationConditions(&$keys, &$data) {
        $conditions = "";
        foreach ($keys as $key) {
            $conditions .= ($conditions == '' ? '' : ' AND ') .
                    "`$key` = " . (is_string($data[$key]) ?
                    "'" . $this->driver->escapeString($data[$key]) . "'" :
                    $data[$key]);
        }
        return $conditions;
    }

    /**
     * Returns list of properties creating together a superkey for the
     * record.
     *
     * @param Array $properties List of properties of the record.
     * @return Array List of properties of the record that can be used as
     *         superkey.
     */
    private function &getKeyProperties(&$properties) {
        $props = &$this->getDBProperties($properties);
        foreach ($props as $name => $type) {
            if (strpos($type, 'P') !== false) {
                $keys = array($name);
                return $keys;
            }
            if (strpos($type, 'U') !== false) {
                $keys = array($name);
                return $keys;
            }
        }
        $props = array_keys($props);
        return $props;
    }

    /**
     * Filters the properties of the record and returns only those that are
     * stored in the database.
     *
     * @param Array $properties All properties of the record.
     * @return Array Properties stored in the database.
     */
    private function &getDBProperties(&$properties) {
        $columns = array();
        foreach ($properties as $name => $type) {
            if (!is_array($type) && (substr($type, 0, 1) != 'r') &&
                (substr($type, 0, 1) != 'e')) {
                $columns[$name] = $type;
            }
        }
        return $columns;
    }

    /**
     * Creates records from loaded data and data model.
     *
     * @param Array $dataModel Data model od record.
     * @param Array $data Data loaded by DBDriver
     * @return Array Array of loaded recordrs.
     */
    private function &createRecords(&$dataModel, &$data) {
        $records = array();
        foreach ($data as $row) {
            $record = array('table' => $dataModel['table'], 'properties' =>
                    $dataModel['properties'], 'data' => $row);
            $this->evalProperties($record);
            $records[] = $record;
        }
        return $records;
    }

    /**
     * Evaluates relation data properties of record. The method changes
     * record given as parameter and does not return any value.
     *
     * @param Array $record Record.
     */
    private function evalProperties(&$record) {
        foreach ($record['properties'] as $name => $type) {
            if (is_string($type) && (substr($type, 0, 1) == 'r')) {
                $relation = substr($type, 1);
                $record['data'][$name] = $this->evalRelation($relation,
                        $record['data']);
            }
        }
    }

    /**
     * Evaluates given relation between properties of record.
     *
     * @param String $relation Relation to evaluate.
     * @param Array $data Array of record data.
     * @return float Evaluation result.
     */
    private function evalRelation($relation, $data) {
        $ast = &$this->buildAST($relation);
        $this->setASTValues($ast, $data);
        return $this->evaluateAST($ast);
    }

    /**
     * Evaluates given abstract syntax tree.
     *
     * @param Array $ast Abstract syntax tree with numeric leaves.
     * @return float Evaluation result.
     */
    private function evaluateAST($ast) {
        if (is_array($ast)) {
            if (is_array($ast['left'])) {
                $ast['left'] = $this->evaluateAST($ast['left']);
            }
            if (is_array($ast['right'])) {
                $ast['right'] = $this->evaluateAST($ast['right']);
            }
            if ($ast['operator'] == '+') {
                return (float) $ast['left'] + (float) $ast['right'];
            } elseif ($ast['operator'] == '-') {
                return (float) $ast['left'] - (float) $ast['right'];
            } elseif ($ast['operator'] == '*') {
                return (float) $ast['left'] * (float) $ast['right'];
            } elseif ($ast['operator'] == '/') {
                return (float) $ast['left'] / (float) $ast['right'];
            }
        } else {
            return (float) $ast;
        }
    }

    /**
     * Swaps all property refferences for their values.
     *
     * @param Array $ast Abstract syntaxt tree of relation.
     * @param Array $data Data of the record.
     */
    private function setASTValues(&$ast, &$data) {
        if (is_string($ast)) {
            $ast = $data[$ast];
            return;
        }

        if (is_string($ast['left'])) {
            $ast['left'] = $data[$ast['left']];
        }
        if (is_string($ast['right'])) {
            $ast['right'] = $data[$ast['right']];
        }

        if (is_array($ast['left'])) {
            $this->setASTValues($ast['left'], $data);
        }
        if (is_array($ast['right'])) {
            $this->setASTValues($ast['right'], $data);
        }
    }

    /**
     * Creates abstract syntax tree of the relation. The tree has following
     * syntax (example):
     * [
     *   'operator'=> '+',
     *   'left' => 52.2,
     *   'right' => [
     *      'operator' => '-',
     *      'left' => 'sallary',
     *      'right' => 10
     *   ]
     * ]
     *
     * @param String $relation Relation to be parsed.
     * @return mixed Abstract syntax tree of the relation.
     */
    private function &buildAST($relation) {
        $relation = trim($relation);
        if ((substr($relation, 0, 1) == '(') &&
                (substr($relation, -1, 1) == ')')) {
            $relation = substr($relation, 1, -1);
        }
        if (is_numeric($relation)) {
            return (float) $relation;
        }
        if ($this->isPrimitiveRelation($relation)) {
            return $relation;
        }

        $node = &$this->buildASTNode($relation);
        $node['left'] = &$this->buildAST($node['left']);
        $node['right'] = &$this->buildAST($node['right']);
        return $node;
    }

    /**
     * Finds the position of operator in non-primitive relation where the
     * relation may be split in two. Then creates AST node by splitting the
     * relation.
     *
     * @param String $relation Relation.
     * @return Array AST node.
     */
    private function &buildASTNode($relation) {
        $operators = array('+', '-', '*', '/');
        foreach ($operators as $operator) {
            $offset = 0;
            while (strpos($relation, $operator, $offset) !== false) {
                $offset = strpos($relation, $operator, $offset) + 1;
                $left = substr($relation, 0, $offset - 1);
                $right = substr($relation, $offset);
                if ($this->goodParenthesis($left) &&
                        $this->goodParenthesis($right)) {
                    $node = array('operator' => $operator, 'left' => $left,
                            'right' => $right);
                    return $node;
                }
            }
        }
        return null;
    }

    /**
     * Checks whether the relation has correctly put parenthesis.
     *
     * @param String $relation Relation to check.
     * @return boolean True if the parenthesis are good, false otherwise.
     */
    private function goodParenthesis($relation) {
        $num = 0;
        for ($i = 0; $i < strlen($relation); $i++) {
            $char = substr($relation, $i, 1);
            if ($char == '(') {
                $num++;
            } elseif ($char == ')') {
                $num--;
            }
            if ($num < 0) {
                return false;
            }
        }
        return true;
    }

    /**
     * Check whether the relation is primite - single number or refference to
     * another value.
     *
     * @param String $relation Relation.
     * @return boolean True if the relation is primitive, false otherwise.
     */
    private function isPrimitiveRelation($relation) {
        $check = array('+', '-', '*', '/', '(');
        foreach ($check as $element) {
            if (strpos($relation, $element) !== false) {
                return false;
            }
        }
        return true;
    }
}
?>
